테스트

aion-server 4.8

Gitteol
최고관리자 · 1 · 💬 0 클론/새로받기
 4.8 61f661d · 1 commits 새로받기(Pull)
game-server/src/com/aionemu/gameserver/controllers/CreatureController.java
package com.aionemu.gameserver.controllers;

import java.util.List;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;

import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.aionemu.commons.utils.Rnd;
import com.aionemu.gameserver.ai.AISubState;
import com.aionemu.gameserver.ai.NpcAI;
import com.aionemu.gameserver.ai.event.AIEventType;
import com.aionemu.gameserver.controllers.attack.AttackResult;
import com.aionemu.gameserver.controllers.attack.AttackStatus;
import com.aionemu.gameserver.controllers.attack.AttackUtil;
import com.aionemu.gameserver.controllers.observer.TerrainZoneCollisionMaterialActor;
import com.aionemu.gameserver.dataholders.DataManager;
import com.aionemu.gameserver.model.EmotionType;
import com.aionemu.gameserver.model.TaskId;
import com.aionemu.gameserver.model.animations.AttackHandAnimation;
import com.aionemu.gameserver.model.animations.AttackTypeAnimation;
import com.aionemu.gameserver.model.animations.ObjectDeleteAnimation;
import com.aionemu.gameserver.model.gameobjects.Creature;
import com.aionemu.gameserver.model.gameobjects.Item;
import com.aionemu.gameserver.model.gameobjects.Npc;
import com.aionemu.gameserver.model.gameobjects.VisibleObject;
import com.aionemu.gameserver.model.gameobjects.player.Player;
import com.aionemu.gameserver.model.gameobjects.state.CreatureState;
import com.aionemu.gameserver.model.items.GodStone;
import com.aionemu.gameserver.model.items.ItemSlot;
import com.aionemu.gameserver.model.stats.container.StatEnum;
import com.aionemu.gameserver.model.templates.item.GodstoneInfo;
import com.aionemu.gameserver.model.templates.item.ItemAttackType;
import com.aionemu.gameserver.model.templates.item.ItemTemplate;
import com.aionemu.gameserver.network.aion.serverpackets.SM_ATTACK;
import com.aionemu.gameserver.network.aion.serverpackets.SM_ATTACK_STATUS.LOG;
import com.aionemu.gameserver.network.aion.serverpackets.SM_ATTACK_STATUS.TYPE;
import com.aionemu.gameserver.network.aion.serverpackets.SM_EMOTION;
import com.aionemu.gameserver.network.aion.serverpackets.SM_SKILL_CANCEL;
import com.aionemu.gameserver.network.aion.serverpackets.SM_SYSTEM_MESSAGE;
import com.aionemu.gameserver.services.item.ItemPacketService;
import com.aionemu.gameserver.skillengine.SkillEngine;
import com.aionemu.gameserver.skillengine.condition.SkillChargeCondition;
import com.aionemu.gameserver.skillengine.model.*;
import com.aionemu.gameserver.skillengine.model.Skill.SkillMethod;
import com.aionemu.gameserver.skillengine.properties.Properties.CastState;
import com.aionemu.gameserver.taskmanager.tasks.MovementNotifyTask;
import com.aionemu.gameserver.utils.PacketSendUtility;
import com.aionemu.gameserver.utils.ThreadPoolManager;
import com.aionemu.gameserver.utils.audit.AuditLogger;
import com.aionemu.gameserver.utils.stats.CalculationType;
import com.aionemu.gameserver.world.geo.GeoService;
import com.aionemu.gameserver.world.zone.ZoneInstance;
import com.aionemu.gameserver.world.zone.ZoneUpdateService;

/**
 * This class is for controlling Creatures [npc's, players etc]
 * 
 * @author -Nemesiss-, ATracer(2009-09-29), Sarynth, Wakizashi
 */
public abstract class CreatureController<T extends Creature> extends VisibleObjectController<T> {

	private static final Logger log = LoggerFactory.getLogger(CreatureController.class);
	private volatile TerrainZoneCollisionMaterialActor actor;
	private final ConcurrentHashMap<Integer, Future<?>> tasks = new ConcurrentHashMap<>();

	@Override
	public void notSee(VisibleObject object, ObjectDeleteAnimation animation) {
		super.notSee(object, animation);
		if (object.equals(getOwner().getTarget()) && getOwner().getAi().getSubState() != AISubState.TARGET_LOST)
			getOwner().setTarget(null);
	}

	@Override
	public void notKnow(VisibleObject object) {
		super.notKnow(object);
		if (object instanceof Creature)
			getOwner().getAggroList().remove((Creature) object);
	}

	/**
	 * Removes owner from the visualObjects lists of all known objects who can't see him anymore.
	 */
	public void onHide() {
		getOwner().getKnownList().forEachObject(other -> other.getKnownList().updateVisibleObject(getOwner()));
	}

	/**
	 * Re-adds owner to the visualObjects lists of all known objects.
	 */
	public void onHideEnd() {
		getOwner().getKnownList().forEachObject(other -> other.getKnownList().updateVisibleObject(getOwner()));
	}

	/**
	 * Perform tasks on Creature starting to move
	 */
	public void onStartMove() {
		getOwner().getMoveController().setInMove(true);
		getOwner().getObserveController().notifyMoveObservers();
		notifyAIOnMove();
	}

	/**
	 * Perform tasks on Creature move in progress
	 */
	public void onMove() {
		getOwner().getObserveController().notifyMoveObservers();
		notifyAIOnMove();
		updateZone();
	}

	/**
	 * Perform tasks on Creature stop move
	 */
	public void onStopMove() {
		getOwner().getMoveController().setInMove(false);
		getOwner().getObserveController().notifyMoveObservers();
		notifyAIOnMove();
	}

	/**
	 * Notify everyone in knownlist about move event
	 */
	protected void notifyAIOnMove() {
		MovementNotifyTask.getInstance().add(getOwner());
	}

	/**
	 * Zone update mask management
	 */
	public final void updateZone() {
		ZoneUpdateService.getInstance().add(getOwner());
	}

	/**
	 * Will be called by ZoneManager when creature enters specific zone
	 */
	public void onEnterZone(ZoneInstance zoneInstance) {
	}

	/**
	 * Will be called by ZoneManager when player leaves specific zone
	 */
	public void onLeaveZone(ZoneInstance zoneInstance) {
	}

	/**
	 * Perform tasks on Creature death.<br>
	 * Should ONLY be called from {@link com.aionemu.gameserver.model.stats.container.CreatureLifeStats} to avoid duplicate death events.
	 */
	public void onDie(Creature lastAttacker) {
		getOwner().getMoveController().abortMove();
		getOwner().setCasting(null);
		getOwner().getEffectController().removeAllEffects();
		if (getOwner() instanceof Player && ((Player) getOwner()).getIsFlyingBeforeDeath()) {
			getOwner().unsetState(CreatureState.ACTIVE);
			getOwner().setState(CreatureState.FLOATING_CORPSE);
		} else
			getOwner().setState(CreatureState.DEAD);
		getOwner().getObserveController().notifyDeathObservers(lastAttacker);
		PacketSendUtility.broadcastPacketAndReceive(getOwner(),
			new SM_EMOTION(getOwner(), EmotionType.DIE, 0, getOwner().equals(lastAttacker) ? 0 : lastAttacker.getObjectId()));
	}

	/**
	 * Called when the creature gains or loses hate towards the attacker
	 */
	public void onAddHate(Creature attacker, boolean isNewInAggroList) {
		getOwner().getAi().onCreatureEvent(AIEventType.ATTACK, attacker);
	}

	/**
	 * Perform tasks when Creature was attacked
	 */
	public final void onAttack(Creature creature, int damage, AttackStatus attackStatus) {
		onAttack(creature, null, TYPE.REGULAR, damage, true, LOG.REGULAR, attackStatus, HopType.DAMAGE);
	}

	public final void onAttack(Creature creature, int damage, AttackStatus attackStatus, Effect criticalEffect) {
		onAttack(creature, null, TYPE.REGULAR, damage, true, LOG.REGULAR, attackStatus, HopType.DAMAGE, criticalEffect);
	}

	public final void onAttack(Effect effect, TYPE type, int damage, boolean notifyAttack, LOG logId, HopType hopType) {
		onAttack(effect.getEffector(), effect, type, damage, notifyAttack, logId, effect.getAttackStatus(), hopType, null);
	}

	public void onAttack(Creature attacker, Effect effect, TYPE type, int damage, boolean notifyAttack, LOG logId, AttackStatus status, HopType hopType) {
		onAttack(attacker, effect, type, damage, notifyAttack, logId, status, hopType, null);
	}

	/**
	 * Perform tasks when Creature was attacked
	 */
	private void onAttack(Creature attacker, Effect effect, TYPE type, int damage, boolean notifyAttack, LOG logId, AttackStatus status, HopType hopType, Effect criticalEffect) {
		if (!getOwner().isSpawned())
			return;
		if (damage != 0 && notifyAttack) {
			Skill skill = getOwner().getCastingSkill();
			if (skill != null) {
				if (skill.getSkillMethod() == SkillMethod.ITEM) {
					cancelCurrentSkill(attacker);
				} else {
					int cancelRate = skill.getSkillTemplate().getCancelRate();
					if (cancelRate >= 99999) {
						cancelCurrentSkill(attacker);
					} else if (cancelRate > 0 && !(getOwner() instanceof Npc && ((Npc) getOwner()).isBoss())) {
						int conc = getOwner().getGameStats().getStat(StatEnum.CONCENTRATION, 0).getCurrent();
						float maxHp = getOwner().getGameStats().getMaxHp().getCurrent();
						int cancel = Math.round(((7f * (damage / maxHp) * 100f) - conc / 2f) * (cancelRate / 100f));
						if (Rnd.chance() < cancel)
							cancelCurrentSkill(attacker);
					}
				}
			}
			getOwner().getObserveController().notifyAttackedObservers(attacker, effect == null ? 0 : effect.getSkillId());
		}

		getOwner().getAggroList().addDamage(attacker, damage, notifyAttack, hopType);

		// notify all NPC's around that creature is attacking me
		getOwner().getKnownList().forEachNpc(npc -> npc.getAi().onCreatureEvent(AIEventType.CREATURE_NEEDS_SUPPORT, getOwner()));
		getOwner().getLifeStats().reduceHp(type, damage, effect == null ? 0 : effect.getSkillId(), logId, attacker);
		getOwner().incrementAttackedCount();

		if (!getOwner().isDead() && attacker instanceof Player player) {
			if (criticalEffect != null) {
				criticalEffect.applyEffect();
			}
			if ((effect == null || effect.tryActivateGodstone()) && status != AttackStatus.DODGE && status != AttackStatus.RESIST)
				calculateGodStoneEffects(player);
		}
		if (effect != null && type == TYPE.DELAYDAMAGE)
			effect.broadcastHate();
	}

	private void calculateGodStoneEffects(Player attacker) {
		applyGodStoneEffect(attacker, attacker.getEquipment().getMainHandWeapon(), true);
		applyGodStoneEffect(attacker, attacker.getEquipment().getOffHandWeapon(), false);
	}

	@SuppressWarnings("lossy-conversions")
	private void applyGodStoneEffect(Player attacker, Item weapon, boolean isMainHandWeapon) {
		if (weapon == null || !weapon.hasGodStone())
			return;
		GodStone godStone = weapon.getGodStone();
		if (!godStone.tryActivate(isMainHandWeapon, getOwner()))
			return;

		GodstoneInfo godstoneInfo = godStone.getGodstoneInfo();
		ItemTemplate template = DataManager.ITEM_DATA.getItemTemplate(godStone.getItemId());
		Skill skill = SkillEngine.getInstance().getSkill(attacker, godstoneInfo.getSkillId(), godstoneInfo.getSkillLevel(), getOwner(), template);
		skill.setFirstTargetRangeCheck(false);
		if (!skill.canUseSkill(CastState.CAST_START))
			return;
		Effect effect = new Effect(skill, getOwner());
		effect.initialize();
		effect.applyEffect();
		PacketSendUtility.sendPacket(attacker, SM_SYSTEM_MESSAGE.STR_SKILL_PROC_EFFECT_OCCURRED(skill.getSkillTemplate().getL10n()));
		// Illusion Godstones
		if (godstoneInfo.getBreakProb() > 0) {
			godStone.increaseActivatedCount();
			if (godStone.getActivatedCount() > godstoneInfo.getNonBreakCount() && Rnd.get(1, 1000) <= godstoneInfo.getBreakProb()) {
				// TODO: Delay 10 Minutes, send messages etc
				// PacketSendUtility.sendPacket(owner, SM_SYSTEM_MESSAGE.STR_MSG_BREAK_PROC_REMAIN_START(equippedItem.getL10n(),
				// itemTemplate.getL10nId()));
				weapon.setGodStone(null);
				PacketSendUtility.sendPacket(attacker,
					SM_SYSTEM_MESSAGE.STR_MSG_BREAK_PROC(weapon.getL10n(), DataManager.ITEM_DATA.getItemTemplate(godStone.getItemId()).getL10n()));
				ItemPacketService.updateItemAfterInfoChange(attacker, weapon);
			}
		}
	}

	/**
	 * Perform reward operation
	 */
	public void doReward() {
	}

	public void onDialogRequest(Player player) {
	}

	public void attackTarget(Creature target, int time, boolean skipChecks) {
		boolean addAttackObservers = true;
		if (!skipChecks
			&& (target == null || getOwner().isDead() || getOwner().getLifeStats().isAboutToDie() || !getOwner().canAttack() || !getOwner().isSpawned())) {
			return;
		}

		// Calculate and apply damage
		AttackHandAnimation attackHandAnimation = AttackHandAnimation.MAIN_HAND;
		AttackTypeAnimation attackTypeAnimation = AttackTypeAnimation.MELEE;
		List<AttackResult> attackResult;

		CalculationType[] calculationTypes = new CalculationType[] { CalculationType.APPLY_POWER_SHARD_DAMAGE, CalculationType.REMOVE_POWER_SHARD };
		if (getOwner() instanceof Player p && p.getEquipment().hasDualWeaponEquipped(ItemSlot.LEFT_HAND))
			calculationTypes = ArrayUtils.add(calculationTypes, CalculationType.DUAL_WIELD);
		if (getOwner().getAttackType() == ItemAttackType.PHYSICAL)
			attackResult = AttackUtil.calculatePhysAttackResult(getOwner(), target, calculationTypes);
		else {
			attackResult = AttackUtil.calculateMagAttackResult(getOwner(), target, getOwner().getAttackType().getMagicalElement(), calculationTypes);
			attackHandAnimation = AttackHandAnimation.OFF_HAND;
		}
		if (getOwner() instanceof Npc) {
			attackHandAnimation = getOwner().getAi().modifyAttackHandAnimation(attackHandAnimation);
			attackTypeAnimation = getOwner().getAi().getAttackTypeAnimation(target);
		}

		int damage = 0;
		for (AttackResult result : attackResult) {
			if (result.getAttackStatus() == AttackStatus.RESIST || result.getAttackStatus() == AttackStatus.DODGE)
				addAttackObservers = false;
			damage += result.getDamage();
		}

		AttackStatus firstAttackStatus = AttackStatus.getBaseStatus(attackResult.getFirst().getAttackStatus());
		Effect criticalEffect = null;
		if (getOwner() instanceof Player player && firstAttackStatus == AttackStatus.CRITICAL && Rnd.chance() < 10) {
			criticalEffect = SkillEngine.getInstance().createCriticalEffect(player, target, 0);
			if (criticalEffect != null && (criticalEffect.getEffectResult() == EffectResult.DODGE || criticalEffect.getEffectResult() == EffectResult.RESIST))
				criticalEffect = null;
		}
		PacketSendUtility.broadcastPacketAndReceive(getOwner(),
			new SM_ATTACK(getOwner(), target, getOwner().getGameStats().getAttackCounter(), time, attackTypeAnimation, attackHandAnimation, attackResult, criticalEffect),
			AIEventType.CREATURE_NEEDS_HELP);

		getOwner().getGameStats().increaseAttackCounter();
		if (addAttackObservers) {
			getOwner().getObserveController().notifyAttackObservers(target, 0);
		}

		if (time == 0)
			target.getController().onAttack(getOwner(), damage, firstAttackStatus, criticalEffect);
		else
			ThreadPoolManager.getInstance().schedule(new DelayedOnAttack(target, getOwner(), damage, firstAttackStatus, criticalEffect), time);
	}

	/**
	 * Handle dialog select: getOwner() is the target or dialog sender, the given player is the one who clicked the dialog
	 */
	public void onDialogSelect(int dialogActionId, int prevDialogId, Player player, int questId, int extendedRewardIndex) {
	}

	public boolean hasTask(TaskId taskId) {
		return tasks.containsKey(taskId.ordinal());
	}

	public boolean hasScheduledTask(TaskId taskId) {
		Future<?> task = tasks.get(taskId.ordinal());
		return task != null && !task.isDone();
	}

	public Future<?> getAndRemoveTask(TaskId taskId) {
		return tasks.remove(taskId.ordinal());
	}

	public Future<?> cancelTask(TaskId taskId) {
		Future<?> task = getAndRemoveTask(taskId);
		if (task != null) {
			task.cancel(false);
		}
		return task;
	}

	public boolean cancelTaskIfPresent(TaskId taskId, Future<?> task) {
		if (tasks.remove(taskId.ordinal(), task)) {
			task.cancel(false);
			return true;
		}
		return false;
	}

	/**
	 * If task already exist - it will be canceled
	 */
	public void addTask(TaskId taskId, Future<?> task) {
		tasks.compute(taskId.ordinal(), (k, oldTask) -> {
			if (oldTask != null) {
				oldTask.cancel(false);
				if (taskId == TaskId.DESPAWN) {
					log.warn("Despawn task for " + getOwner() + " was cancelled and replaced with another one, possibly delaying the intended despawn time.");
				}
			}
			return task;
		});
	}

	/**
	 * Cancel all tasks associated with this controller
	 */
	public void cancelAllTasks() {
		for (Entry<Integer, Future<?>> e : tasks.entrySet()) {
			Future<?> task = e.getValue();
			if (task != null)
				task.cancel(false);
		}
		tasks.clear();
	}

	@Override
	public void onDelete() {
		cancelAllTasks();
		super.onDelete();
	}

	/**
	 * Die by reducing HP to 0
	 */
	public boolean die() {
		return die(null, null, getOwner());
	}

	public boolean die(Creature lastAttacker) {
		return die(null, null, lastAttacker);
	}

	public boolean die(TYPE type, LOG log, Creature lastAttacker) {
		return getOwner().getLifeStats().reduceHp(type, Integer.MAX_VALUE, 0, log, lastAttacker) == 0;
	}

	/**
	 * Use skill with default level 1
	 */
	public final boolean useSkill(int skillId) {
		return useSkill(skillId, 1);
	}

	/**
	 * @return true if successful usage
	 */
	public boolean useSkill(int skillId, int skillLevel) {
		try {
			Creature creature = getOwner();
			Skill skill = SkillEngine.getInstance().getSkill(creature, skillId, skillLevel, creature.getTarget());
			if (skill != null) {
				return skill.useSkill();
			}
		} catch (Exception ex) {
			log.error("Exception during skill use: " + skillId, ex);
		}
		return false;
	}

	public boolean useChargeSkill(Skill startSkill, long chargeTimeMillis) {
		SkillChargeCondition chargeCondition = startSkill.getSkillTemplate().getSkillChargeCondition();
		ChargeSkillEntry chargeSkill = chargeCondition == null ? null : DataManager.SKILL_CHARGE_DATA.getChargedSkillEntry(chargeCondition.getValue());
		if (chargeSkill == null || chargeTimeMillis < chargeSkill.getMinTime() * startSkill.getCastSpeedForAnimationBoostAndChargeSkills()) {
			if (getOwner() instanceof Player player)
				AuditLogger.log(player, "tried to use charge skill " + startSkill.getSkillId() + " after " + chargeTimeMillis);
			return false;
		}
		try {
			int index = 0, chargeTimeSum = 0;
			for (ChargedSkill skill : chargeSkill.getSkills()) {
				chargeTimeSum += (int) (skill.getTime() * startSkill.getCastSpeedForAnimationBoostAndChargeSkills());
				if (chargeTimeSum >= chargeTimeMillis || ++index == chargeSkill.getSkills().size() - 1)
					break;
			}
			int skillId = chargeSkill.getSkills().get(index).getId();
			ChargeSkill skill = SkillEngine.getInstance().getChargeSkill(getOwner(), skillId, startSkill.getSkillLevel(), index + 1, startSkill);
			if (skill != null)
				return skill.useSkill();
		} catch (Exception ex) {
			log.error("Could not use charge skill " + startSkill.getSkillId() + " with charge time " + chargeTimeMillis, ex);
		} finally {
			startSkill.cancelCast();
		}
		return false;
	}

	public Skill abortCast() {
		Creature creature = getOwner();
		Skill castingSkill = creature.getCastingSkill();
		if (castingSkill != null) {
			castingSkill.cancelCast();
			creature.setCasting(null);
			if (creature instanceof Npc) {
				creature.getAi().setSubStateIfNot(AISubState.NONE);
				((Npc) creature).getGameStats().setLastSkill(null);
			}
		}
		return castingSkill;
	}

	public void cancelCurrentSkill(Creature lastAttacker) {
		cancelCurrentSkill(lastAttacker, null);
	}

	/**
	 * Cancel current skill and remove cooldown
	 */
	public void cancelCurrentSkill(Creature lastAttacker, SM_SYSTEM_MESSAGE msg) {
		Skill castingSkill = abortCast();
		if (castingSkill == null)
			return;

		PacketSendUtility.broadcastPacketAndReceive(getOwner(), new SM_SKILL_CANCEL(getOwner(), castingSkill.getSkillTemplate().getSkillId()));
		if (getOwner().getAi() instanceof NpcAI npcAI) {
				npcAI.onGeneralEvent(AIEventType.ATTACK_COMPLETE);
		}
		if (lastAttacker instanceof Player) {
			PacketSendUtility.sendPacket((Player) lastAttacker, SM_SYSTEM_MESSAGE.STR_SKILL_TARGET_SKILL_CANCELED());
		}
	}

	/**
	 * Cancel use Item
	 */
	public void cancelUseItem() {
	}

	@Override
	public void onAfterSpawn() {
		super.onAfterSpawn();
		getOwner().revalidateZones();
		if (actor == null && getOwner().getMoveController() != null && GeoService.getInstance().worldHasTerrainMaterials(getOwner().getWorldId())) {
			actor = new TerrainZoneCollisionMaterialActor(getOwner());
			getOwner().getObserveController().addObserver(actor);
		}
	}

	@Override
	public void onDespawn() {
		super.onDespawn();
		if (actor != null) {
			actor.abort();
			getOwner().getObserveController().removeObserver(actor);
			actor = null;
		}
		cancelTask(TaskId.DECAY);
		getOwner().getMoveController().abortMove();
		getOwner().getAggroList().clear();
	}

	private static final class DelayedOnAttack implements Runnable {

		private Creature target;
		private Creature creature;
		private int finalDamage;
		private AttackStatus attackStatus;
		private Effect criticalEffect;

		private DelayedOnAttack(Creature target, Creature creature, int finalDamage, AttackStatus attackStatus, Effect criticalEffect) {
			this.target = target;
			this.creature = creature;
			this.finalDamage = finalDamage;
			this.attackStatus = attackStatus;
			this.criticalEffect = criticalEffect;
		}

		@Override
		public void run() {
			target.getController().onAttack(creature, finalDamage, attackStatus, criticalEffect);
			target = null;
			creature = null;
			criticalEffect = null;
		}

	}

}

📎 첨부파일

댓글 작성 권한이 없습니다.
🏆 포인트 랭킹 TOP 10
순위 닉네임 포인트
1 no_profile 타키야겐지쪽지보내기 자기소개 아이디로 검색 전체게시물 102,949
2 no_profile 동가리쪽지보내기 자기소개 아이디로 검색 전체게시물 63,733
3 no_profile 라프텔쪽지보내기 자기소개 아이디로 검색 전체게시물 51,771
4 no_profile 불멸의행복쪽지보내기 자기소개 아이디로 검색 전체게시물 36,923
5 서번트쪽지보내기 자기소개 아이디로 검색 전체게시물 35,011
6 no_profile 닥터스쪽지보내기 자기소개 아이디로 검색 전체게시물 29,470
7 no_profile 검은고양이쪽지보내기 자기소개 아이디로 검색 전체게시물 29,077
8 no_profile Revolution쪽지보내기 자기소개 아이디로 검색 전체게시물 28,199
9 no_profile 보거스쪽지보내기 자기소개 아이디로 검색 전체게시물 26,731
10 no_profile 호롤롤로쪽지보내기 자기소개 아이디로 검색 전체게시물 17,020
알림 0